NavigationPage + MasterDetailPage の時に iOS の NavigationBar の左ボタンをカスタマイズする

Xamarin.Forms では、左からスライドして出てくるメニューを持つ画面を MasterDetailPage で作成します。

一方、普通に画面遷移していく場合は ContentPage などを NavigationPage でラップしてあげます。

やりたいこと

何がしたいかというと、両者を組み合わせたいんです。こういうことってよくありませんかね?

customizing left navvigationbar button in masterdetail with navigation pages 01

起動画面で「新規ユーザー登録」があって、「ユーザー登録画面」を経て、メインの画面に遷移する、メイン画面にはスライドメニューがある、というパターン。これを Xamarin.Forms でやりたいのです。

問題

ところが、 NavigationPage で遷移していく画面の中に MasterDetailPage があると、 NavigationPage の方が勝ってしまい、ナビゲーションバーには「BACK」ボタンが表示されてしまいます。

customizing left navvigationbar button in masterdetail with navigation pages 02

これを消そうと、MasterDetailPage のコンストラクタで NavigationPage.SetHasBackButton(this, false) してみます。

その結果がこれ。

customizing left navvigationbar button in masterdetail with navigation pages 03

Android の方は望む結果になったけど、iOSの方はうーん…、BACKボタンは消えたけど、メニューを表示させるボタンが出ません。

しょうがないので、iOS の場合だけ、ナビゲーションバーの左ボタンをどうにかして追加してみます。

私が求めていたソリューション(CustomRenderer編)

Xamarin.Forms のお供、CustomRenderer です。

MasterDetailPage の iOS向けCustomRenderer を作って、ネイティブ側でナビゲーションバーをカスタマイズしてみます。

// CustomMasterDetailRenderer.cs
using System;
using MasterDetail.iOS;
using MonoTouch.UIKit;
using Xamarin.Forms;
using Xamarin.Forms.Platform.iOS;

[assembly: ExportRenderer(typeof(MasterDetailPage), typeof(CustomMasterDetailRenderer))]
namespace MasterDetail.iOS
{
    public class CustomMasterDetailRenderer : PhoneMasterDetailRenderer
    {
        public override void ViewWillAppear(bool animated)
        {
            base.ViewWillAppear(animated);

            var page = Element as MasterDetailPage;

            var navigationItem = this.NavigationController.TopViewController.NavigationItem;
            navigationItem.LeftBarButtonItems = new UIBarButtonItem[]
            {
                new UIBarButtonItem("MENU", UIBarButtonItemStyle.Plain, (_, __) => 
                { 
                    page.IsPresented = !page.IsPresented; 
                })
            };
        }
    }
}

「MENU」ってボタンを、ナビゲーションバーの左側に追加しています。 こんな CustomRenderer を iOS 側のプロジェクトに追加して実行してみます。

customizing left navvigationbar button in masterdetail with navigation pages 04

オーケーオーケー、これが私が求めていたソリューションです。

私が求めていたソリューション(Effects編)

が、CustomRenderer にはいくつか考えなければならないことがあります。

  • 上で作った CustomMasterDetailRenderer.csPhoneMasterDetailRenderer というクラスを継承しています。が、実はこれは iPhone 用で、実はタブレット(iPad)用に TabletMasterDetailRenderer というクラスもあります。これの CustomRenderer も用意しなければなりませんか?
  • CustomRenderer はベースとなる ViewRenderer を「継承」して作ります。そして C# は多重継承を許していません、この意味が分かるな?別の機能を拡張したいと思ったらCustomMasterDetailRendererから派生させるしかなくなります。

で、 Xamarin.Forms には、v2.1 から既存機能の拡張に Effects という選択肢が加わりました。

では、CustomMasterDetailRenderer.cs を Effects に変えてみましょう。

// CustomMasterDetailEffect.cs
using System;
using MasterDetail.iOS;
using MonoTouch.UIKit;
using Xamarin.Forms;
using Xamarin.Forms.Platform.iOS;

[assembly: ResolutionGroupName("mycompany")]
[assembly: ExportEffect(typeof(CustomMasterDetailEffect), "CustomMasterDetailEffect")]
namespace MasterDetail.iOS
{
    public class CustomMasterDetailEffect : PlatformEffect
    {
        protected override void OnAttached()
        {
            var page = Element as MasterDetailPage;
            page.Appearing += Page_Appearing;
        }

        protected override void OnDetached()
        {
            var page = Element as MasterDetailPage;
            page.Appearing -= Page_Appearing;
        }

        void Page_Appearing(object sender, EventArgs e)
        {
            var vc = GetParentViewController();
            var page = Element as MasterDetailPage;

            var navigationItem = vc.NavigationController.TopViewController.NavigationItem;
            navigationItem.LeftBarButtonItems = new UIBarButtonItem[]
            {
                new UIBarButtonItem("MENU", UIBarButtonItemStyle.Plain, (_, __) => 
                { 
                    page.IsPresented = !page.IsPresented; 
                })
            };
        }

        UIViewController GetParentViewController()
        {
            UIResponder responder = this.Container;
            while ((responder = responder.NextResponder) != null)
            {
                if (responder is UIViewController)
                {
                    return (UIViewController)responder;
                }
            }
            return null;
        }
    }
}

Effect は、 ResolutionGroupNameExportEffect で定義した名称を使って、PCL側プロジェクトで Page に追加します。

// RootPage.cs
public class RootPage : MasterDetailPage
{
    public RootPage ()
    {
        NavigationPage.SetHasBackButton(this, false);
        // Effect を追加する
        Effects.Add(Effect.Resolve("mycompany.CustomMasterDetailEffect"));

        // 以下省略

customizing left navvigationbar button in masterdetail with navigation pages 05

こちらも、CustomRenderer と同じことができました。

が、ちょっと黒魔術っぽいの使ってます。

  • Effects.Container から取得できるのは UIView です。親の UIViewController を得るには、 GetParentViewController() でやってるような事をしなければなりません
  • CustomRenderer はそれ自体は ViewController だったので ViewWillAppear() など画面のライフサイクルコールバックを override することができました。が、Effects から ViewController のライフサイクルイベントをハンドリングできません。代わりに Xamarin.Forms 側の Page のライフサイクルから Appearing イベントで処理するようにしています。そのため、「MENU」ボタンが表示されるタイミングが若干遅れます。

CustomRenderer と Effects 、どちらを使えばいいの?

  • CustomRenderer はできる事は多いが、複数の CustomRenderer を適用することはできない
  • Effects はできる事は少ないが、複数の機能拡張を同時に適用できる

以上を考えると、

  1. まずあなたの行いたいことが Effects で実現できないか、試してみる
  2. Effects でできないレベルなら CustomRenderer を選択する

となるでしょう。

本件のネタは、 Effects ではかなりムリをして実現しているので、CustomRenderer の方が相応しいと思われます。 が、CustomRenderer はここぞという時にとっておきたい気もします。 このさじ加減は、作るアプリの規模・深度、汎用性、再利用性などによって変わってくるでしょう。Effects の方が汎用性・再利用性は高いですが、ネイティブのUIパーツをごっそり入れ替えるような深い事は、CustomRenderer でなければできません。

今回のプログラムは Github に上げてあります。(CustomMasterDetailRenderer.cs はコメントアウトしてあって、Effects の方を活かしてます。)

私は「Effects で頑張りたい派」かな。